API Response 的格式沒有標準答案,網路上已經有許多範例可以參考,我認為不管格式為何,重點是團隊成員有共識,而且相同類型的 API 格式要固定,否則會造成串接方的困擾。如果你還沒決定好格式,不妨可以參考我的作法。
基本的 ResponseDTO 包含以下欄位
簡單的成功格式有 CodeResponseDTO
及 DataResponseDTO
,CodeResponseDTO 只回應 ResponseCode,DataResponseDTO 則是有回應資料 data
至於錯誤格式的 ErrorResponseDTO
多了以下欄位
例如使用者登入帳密錯誤,此時回應 AUTH_LOGIN_UNAUTHENTICATED
的 type 是 CLIENT_INFO
,前端可以顯示 INFO 等級的訊息文字給使用者。
{
"type": "error",
"code": {
"name": "AUTH_LOGIN_UNAUTHENTICATED",
"value": "2008",
"type": "CLIENT_INFO"
},
"message": "帳號或密碼不正確",
"detail": "[AUTH_LOGIN_UNAUTHENTICATED] ",
"reqId": "3gzwul5brn13-n-leu51bdknmt9+vi31"
}
如果前端不小心漏掉檢查,傳給後端不合法的資料時,此時回應 BAD_REQUEST_BODY
的 type 是 CLIENT_ERROR
,前端可以進行程式修正,如果情境上無法驗證資料,那就顯示錯誤訊息給使用者。
{
"type": "error",
"code": {
"name": "BAD_REQUEST_BODY",
"value": "1004",
"type": "CLIENT_ERROR"
},
"message": "系統無法處理您的請求或請求結果有錯誤",
"detail": "請求的 body 資料格式有錯誤 => [BAD_REQUEST_BODY] Invalid(errors=[ValidationError(dataPath=.account, message=must match the expected pattern)])",
"reqId": "ddsoy+ip2t0vupyrz54bqdujk+6bsgo9"
}
另外還有 PagingDataResponseDTO
用於分頁, BatchResponseDTO
用於批次工作…等其它格式,完整程式碼可以參考 Github Repo
@Serializable
class ResponseCode(
val name: String,
val value: String,
val type: ResponseCodeType,
@Transient val httpStatusCode: HttpStatusCode = HttpStatusCode.OK
)
enum class ResponseCodeType {
SUCCESS,
CLIENT_INFO, // 存在回應給使用者的訊息
CLIENT_ERROR, // 前端請求錯誤
SERVER_ERROR; // 後端處理錯誤
}
@Serializable
sealed class ResponseDTO {
abstract val code: ResponseCode
abstract val message: String?
abstract val data: JsonElement?
}
@Serializable
@SerialName("code")
class CodeResponseDTO(override val code: ResponseCode) : ResponseDTO() {
override val message: String? = null
override val data: JsonElement? = null
companion object {
val OK = CodeResponseDTO(InfraResponseCode.OK)
}
}
@Serializable
@SerialName("data")
class DataResponseDTO(
override val code: ResponseCode = InfraResponseCode.OK,
override val message: String? = null,
override val data: JsonElement
) : ResponseDTO()
@Serializable
@SerialName("error")
class ErrorResponseDTO(
override val code: ResponseCode,
override val message: String,
val detail: String,
val reqId: String,
override val data: JsonObject? = null,
val errors: MutableList<ErrorResponseDetailError>? = null
) : ResponseDTO()
@Serializable
class ErrorResponseDetailError(
val code: ResponseCode,
val detail: String,
val data: JsonObject? = null
)
@Serializable
@SerialName("paging")
class PagingDataResponseDTO(
override val code: ResponseCode = InfraResponseCode.OK,
override val message: String? = null,
override val data: JsonElement
) : ResponseDTO() {
@Serializable
class PagingData(
val total: Long, val totalPages: Long,
val itemsPerPage: Int, val pageIndex: Long,
val items: JsonArray
)
}
@Serializable
@SerialName("batch")
class BatchResponseDTO(
override val code: ResponseCode,
override val message: String,
override val data: JsonObject
) : ResponseDTO()
@Serializable
class BatchResult(val successes: MutableList<SuccessResult>, val failures: MutableList<FailureResult>)
@Serializable
class SuccessResult(override val id: String, val data: JsonElement? = JsonObject(mapOf())) :
IdentifiableObject<String>()
@Serializable
class FailureResult(override val id: String, val errors: MutableList<ErrorResponseDetailError>) : IdentifiableObject<String>()
我習慣 API 回應的訊息文字區分為給一般使用者看的 message 屬性及給前端開發者看的 detail 屬性,其中 message 應該要支援 i18n。昨天提到的多專案 i18n 訊息通知範例,每個子專案可以定義自己的 NotificationType,而且擁有自己的訊息通知語系檔 notification_zh-TW.prperties。同樣地,每個子專案擁有自己的 API,所以每個 API 回應給使用者的訊息文字,也需要可以定義在自己的語系檔 response_zh-TW.conf (HOCON格式)。但是這兩個功能有一個地方不同的是,雖然一樣是多專案架構,但對於許多 ResponseCode 而言是可以讓子專案共用的,例如上面提到的登入失敗 AUTH_LOGIN_UNAUTHENTICATED 及資料驗證錯誤 BAD_REQUEST_BODY
我在底層 infra module 定義這些一般用途可共用的 InfraResponseCode
,另外在2個子專案定義各自的 OpsResponseCode
及 ClubResponseCode
。要注意所有 ResponseCode 的 value 值是全域性的不能重複。
object InfraResponseCode {
val OK = ResponseCode("OK", "0000", ResponseCodeType.SUCCESS, HttpStatusCode.OK)
val BAD_REQUEST_BODY = ResponseCode("BAD_REQUEST_BODY", "1004", ResponseCodeType.CLIENT_ERROR, HttpStatusCode.BadRequest)
val AUTH_LOGIN_UNAUTHENTICATED = ResponseCode("AUTH_LOGIN_UNAUTHENTICATED", "2008", ResponseCodeType.CLIENT_INFO, HttpStatusCode.Unauthorized)
// 其它省略
}
object OpsResponseCode {
val OPS_ERROR = ResponseCode("OPS_ERROR", "3000", ResponseCodeType.SERVER_ERROR, HttpStatusCode.InternalServerError)
}
object ClubResponseCode {
val CLUB_ERROR = ResponseCode("CLUB_ERROR", "4000", ResponseCodeType.SERVER_ERROR, HttpStatusCode.InternalServerError)
}
然後 ResponseCode value 當作 key 對應到訊息文字,以下是各個 response_zh-TW.conf
語系檔內容
// ===== infra module =====
codeType {
SUCCESS = "操作成功",
USER_FAILED = "操作結果失敗",
CLIENT_ERROR = "系統無法處理您的請求或請求結果有錯誤",
SERVER_ERROR = "系統錯誤"
}
code {
0000 = "操作成功",
1004 = "請求的 body 資料格式有錯誤",
2008 = "帳號或密碼不正確"
}
// ===== ops project =====
code {
3000 = "Ops 錯誤"
}
// ===== club project =====
code {
4000 = "Club 錯誤"
}
依照我的 i18n 機制的作法,實作 ResponseMessages
及其 ResponseMessagesProvider
,其中 ResponseMessagesProvider 的 merge
方法會把所有專案的 ResponseMessages 都合併至同一個物件方便操作,底層是透過 com.typesafe.config.Config
的 withFallback
方法實現,這也是我為什麼採用 HOCON 語系檔格式的原因。
class ResponseMessages(val messages: HoconMessagesImpl) : Messages {
fun getMessage(ex: BaseException): String =
if (ex.code.isError()) getCodeTypeMessage(ex.code.type)
else getCodeMessage(ex)
fun getDetailMessage(ex: BaseException): String {
val message = if (ex.code.isError()) getCodeMessage(ex) else ""
return ex.message?.let { if (message.isNotEmpty()) "$message => $it" else it } ?: message
}
private fun getCodeTypeMessage(codeType: ResponseCodeType): String {
return get("codeType.${codeType.name}", null)!!
}
private fun getCodeMessage(ex: BaseException): String {
return if (ex is EntityException) {
val args: MutableMap<String, Any> = mutableMapOf()
if (ex.entity != null) {
args.putAll(ex.entity.toNotNullMap("entity"))
}
if (ex.dataMap != null) {
args.putAll(ex.dataMap)
}
getCodeMessage(ex.code, args)
} else {
getCodeMessage(ex.code, ex.dataMap)
}
}
private fun getCodeMessage(code: ResponseCode, args: Map<String, Any>? = null): String {
val message = get("code.${code.value}", args)
if (code.type == ResponseCodeType.CLIENT_INFO)
requireNotNull(message)
return message ?: ""
}
override val lang: Lang = messages.lang
override fun get(key: String, args: Map<String, Any>?): String? = messages.get(key, args)
override fun isDefined(key: String): Boolean = messages.isDefined(key)
}
class ResponseMessagesProvider(messagesProvider: HoconMessagesProvider) : MessagesProvider<ResponseMessages> {
private val logger = KotlinLogging.logger {}
override val messages: Map<Lang, ResponseMessages> = messagesProvider.messages
.mapValues { ResponseMessages(it.value) }
fun merge(another: HoconMessagesProvider) {
messages.forEach { (lang, responseMessages) ->
another.messages[lang]?.let {
responseMessages.messages.withFallback(it)
}
}
}
}
然後 I18nResponseCreator
負責把 Exception 轉換為 ErrorResponseDTO,並且根據客戶端請求的偏好語言,讀取對應語系檔的訊息文字,再填入到 message 屬性。
class I18nResponseCreator(private val messagesProvider: ResponseMessagesProvider) {
fun createErrorResponse(e: BaseException, call: ApplicationCall): ErrorResponseDTO {
val messages = messagesProvider.preferred(call.lang())
return ErrorResponseDTO(
e.code,
messages.getMessage(e), messages.getDetailMessage(e),
call.callId!!, e.dataMap?.toJsonObject(), null
)
}
}
取得 HTTP Request 偏好語言是透過在 Ktor ApplicationCall
及 ApplicationRequest
類別定義lang()
extension function,從 cookie 或 header Accept-Language
取得。
fun ApplicationRequest.lang(): Lang? = (cookies["lang"] ?: acceptLanguageItems().firstOrNull()?.value)?.let { Lang(it) }
fun ApplicationCall.lang(): Lang? = attributes.getOrNull(Lang.ATTRIBUTE_KEY) ?: request.lang()
這 2 天說明如何建立 Ktor i18n 機制,還有在 multi-project 架構上實作 API Response Message 及 Notification Message。明天的主題將進入 Ktor API Authentication 及 Authorization。